Skip to content

feat(agentex): DM unlinked Slack users a link to connect their account - #412

Merged
michael-chou359 merged 1 commit into
mainfrom
mc/slack-link-dm-trigger
Aug 28, 2026
Merged

feat(agentex): DM unlinked Slack users a link to connect their account#412
michael-chou359 merged 1 commit into
mainfrom
mc/slack-link-dm-trigger

Conversation

@michael-chou359

@michael-chou359 michael-chou359 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What

The last piece of the identity-link flow. #409 added the storage, #410 the gateway
wiring and the link routes — but nothing ever handed a user a link, so the only way
in was a dev script. Now an unlinked mention offers one.

When a turn is not running as a person — unlinked, or linked with a credential we
can't use (expired session, undecryptable ciphertext) — the gateway:

1. mint (or reuse) a nonce carrying the Slack identity Slack's HMAC just verified,
   plus the message that triggered it
2. conversations.open -> chat.postMessage   DM that user the link
3. chat.postEphemeral                       tell them in-channel a DM is waiting

A dead credential gets the same offer as no credential, since re-linking is the fix
for both.

The link is DMed and never posted in a channel

This is the part worth reviewing closely. The nonce is a bearer token: whoever
opens it gets linked to that Slack identity by signing in as themselves. Posted in a
channel, the first person to read it could bind someone else's Slack identity to
their own SGP account.

So when conversations.open or the DM fails, we log and stop — there is no fallback
to anywhere visible. A test asserts the token never appears in any payload addressed
to the origin channel, and another that a failed DM leaks it nowhere.

Best-effort by construction

The turn is already proceeding (as the shared bot, or being refused immediately
after), and nothing in the offer path may change that. A Redis outage, a missing
scope, a closed DM — all end in "no offer" and an unaffected turn.

Offers also require SLACK_GATEWAY_PUBLIC_BASE_URL. Unset means no offers at all:
the host must be browser-reachable and a sibling subdomain of the SGP host or the
session cookie never arrives at the callback. A link that cannot work is worse than
no link.

Rate limiting, two layers for two problems

  • claim_send (from feat(agentex): run Slack turns as the invoking user, and the flow to link them #410) caps DMs about one live link at 2, so a re-mention
    re-sends the same link rather than going quiet.
  • A cooldown key (SLACK_LINK_OFFER_COOLDOWN_S, default 1h) stops a fresh nonce
    from re-arming that budget every time the old one expires. Without it, a persistent
    mentioner collects ~12 DMs an hour instead of ~2. It fails open on a Redis
    error — never offering is the worse failure, and claim_send still bounds it.

Email match — shipped OFF

The nonce stops an attacker forging someone else's Slack identity. It does not
stop them forwarding their own link: if a victim clicks it while signed in, the
attacker's Slack identity binds to the victim's SGP account, and from then on the
attacker's Slack messages run as the victim, with the victim's integrations. The
confirmation page naming both identities catches a mis-click but reduces to user
vigilance against a deliberate attempt.

Comparing the Slack account's email against the signed-in SGP account's closes it.

It ships disabled because it needs the users:read.email Slack scope, which is not
granted
— verified, users.lookupByEmail returns missing_scope. Enable
IDENTITY_LINK_REQUIRE_EMAIL_MATCH and the scope together.

⚠️ The check treats an unreadable email as a MISMATCH, not as "skip". So turning
the flag on without the scope refuses every link. That direction is deliberate:
failing open would silently disable the only defence the moment the scope lapsed.
Tests pin both directions.

Not included

Replaying the stashed turn. pending_turn is recorded for it, but the
confirmation page still says "ask me again in Slack". Wiring the link route back into
the gateway is a coupling worth doing on its own, with care that a replay failure
can't undo a successful link.

Testing

15 new unit tests, aimed at the failure modes rather than the happy path:

  • link offer: the token never reaching the origin channel; a failed DM leaking it
    nowhere; no offer without a public base URL; the send cap acknowledging in-channel
    instead of going silent; the cooldown suppressing before a nonce is even minted; a
    Redis error swallowed; the DM text warning against forwarding; pending_turn
    stashed for a later replay.
  • email match: disabled-by-default doesn't call Slack at all; matching (and
    case-insensitive) emails link; a mismatch refuses and leaves the nonce intact,
    so a legitimate owner can still use their own link; unreadable Slack email and
    missing SGP email both fail closed.

Full unit suite: 685 passed. ruff clean.

The 193 local integration errors are the pre-existing testcontainers/Mongo issue in
files this PR doesn't touch — they reproduce identically on a clean checkout of main.

Deploy notes

Nothing here activates on its own:

Variable Effect if unset
SLACK_GATEWAY_PUBLIC_BASE_URL no offers are sent at all
SLACK_LINK_OFFER_COOLDOWN_S defaults to 1h
IDENTITY_LINK_REQUIRE_EMAIL_MATCH email match skipped (current state)

Scopes: im:write and chat:write are already granted, so DMs and ephemerals work
today. users:read.email is the only addition needed, and only for the email match.

🤖 Generated with Claude Code

Greptile Summary

Adds Slack account-link offers for users whose turns cannot run under a usable personal identity, with bearer links delivered only through DMs and an ephemeral channel acknowledgment.

  • Adds Slack profile lookup and optional Slack/SGP email matching during confirmation.
  • Adds nonce reuse, delivery limits, and a per-user offer cooldown.
  • Adds unit coverage for delivery confidentiality, failure handling, rate limiting, pending turns, and email matching.

Confidence Score: 4/5

The failed-delivery cooldown behavior should be fixed before merging because a transient Slack failure can prevent the user from receiving a link for an hour.

The gateway records the offer cooldown before attempting DM delivery and does not undo it on either Slack failure path, causing later mentions to be suppressed despite no successful offer.

Files Needing Attention: agentex/src/domain/use_cases/slack_gateway_use_case.py

Important Files Changed

Filename Overview
agentex/src/domain/use_cases/slack_gateway_use_case.py Adds the Slack DM offer flow, profile lookup, cooldown, and ephemeral acknowledgment; failed DM delivery incorrectly leaves the cooldown active.
agentex/src/api/routes/integrations.py Adds an explicitly opt-in, fail-closed email correspondence check before persisting a Slack identity link.
agentex/tests/unit/use_cases/test_slack_gateway_use_case.py Covers link confidentiality and major offer outcomes but does not verify that a failed delivery remains retryable.
agentex/tests/unit/api/test_integrations_routes.py Covers enabled, disabled, matching, mismatching, and unreadable-email confirmation behavior.

Sequence Diagram

sequenceDiagram
    participant U as Slack user
    participant G as Slack gateway
    participant R as Redis/nonce service
    participant S as Slack API
    participant I as Link confirmation route
    G->>G: Resolve invoking identity
    alt No usable personal identity
        G->>R: Claim offer cooldown
        G->>R: Create or reuse nonce and claim send
        G->>S: conversations.open
        G->>S: chat.postMessage with bearer link
        G->>S: chat.postEphemeral acknowledgment
        U->>I: Open link and confirm while signed in
        I->>I: Optionally compare Slack and SGP email
        I->>R: Consume nonce and persist identity link
    end
Loading

Fix all with Greploop Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
agentex/src/domain/use_cases/slack_gateway_use_case.py:1270
**Failed DMs consume the cooldown**

When `conversations.open` or `chat.postMessage` fails, the cooldown has already been claimed and is not released, causing every subsequent mention to suppress another offer for up to one hour even though the user received no link.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(agentex): DM unlinked Slack users a..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Completes the identity-link flow. Everything else shipped in #409/#410 and was
reachable only through a dev script, because nothing ever handed a user a link.
Now an unlinked mention offers one.

On a mention where the turn is NOT running as a person -- unlinked, or linked with
a credential we can't use (expired session, undecryptable ciphertext) -- the
gateway:

  1. mints (or reuses) a nonce carrying the Slack identity Slack's HMAC just
     verified, plus the message that triggered it
  2. conversations.open -> chat.postMessage: DMs that user the link
  3. chat.postEphemeral: tells them in-channel that a DM is waiting

A dead credential is offered the same fix as no credential, since re-linking is
the remedy for both.

The link is DMed and NEVER posted in a channel. The nonce is a bearer token:
whoever opens it gets linked to that Slack identity by signing in as themselves.
In a channel, the first person to read it could bind someone else's Slack identity
to their own SGP account. So when conversations.open or the DM fails we log and
stop rather than falling back anywhere visible -- there is a test asserting the
token never appears in a payload addressed to the origin channel.

Entirely best-effort. The turn is already proceeding (as the shared bot, or being
refused just after), and no failure in here changes that: a Redis outage, a
missing scope, a closed DM all end in "no offer" and an unaffected turn.

Rate limiting is two layers, for two different problems:

- claim_send caps DMs about one live link at 2, so a re-mention re-sends the same
  link rather than going quiet.
- a cooldown key (SLACK_LINK_OFFER_COOLDOWN_S, default 1h) stops a fresh nonce
  from re-arming that budget every time the old one expires. Without it a
  persistent mentioner would collect ~12 DMs an hour instead of ~2. It fails OPEN
  on a Redis error, since never offering is worse and claim_send still bounds it.

Offers require SLACK_GATEWAY_PUBLIC_BASE_URL. Unset means no offers at all: the
host has to be browser-reachable AND a sibling subdomain of the SGP host or the
session cookie never arrives, and a link that cannot work is worse than none.

Also adds the email-match defence, OFF by default.

The nonce stops an attacker forging someone else's Slack identity. It does not stop
them forwarding their OWN link: if a victim clicks it while signed in, the
attacker's Slack identity binds to the victim's SGP account, and from then on the
attacker's Slack messages run as the victim with the victim's integrations. The
confirmation page naming both identities catches a mis-click but reduces to user
vigilance against a deliberate attempt. Comparing the Slack account's email to the
signed-in SGP account's closes it.

It ships disabled because it needs the users:read.email Slack scope, which is not
granted (verified: users.lookupByEmail returns missing_scope). Enable
IDENTITY_LINK_REQUIRE_EMAIL_MATCH and the scope together -- the check treats an
unreadable email as a MISMATCH, not as "skip", so turning the flag on without the
scope refuses every link. That direction is deliberate: failing open would
silently disable the only defence the moment the scope lapsed.

Not included: replaying the stashed turn after linking. pending_turn is recorded
for it, but the confirmation page still says "ask me again", and wiring the route
back into the gateway is left for a follow-up.

Testing: 15 new unit tests. The link-offer ones concentrate on where the token must
not go (origin channel, on DM failure) and on the offer never affecting the turn;
the email-match ones on failing closed -- unreadable Slack email, missing SGP
email -- and on the nonce surviving a refusal so a legitimate owner can still use
their own link. Full unit suite: 685 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michael-chou359
michael-chou359 requested a review from a team as a code owner August 28, 2026 06:41

from src.domain.services.link_nonce_service import LinkNonceService, LinkRequest

if not await self._claim_offer_cooldown(inbound):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed DMs consume the cooldown

When conversations.open or chat.postMessage fails, the cooldown has already been claimed and is not released, causing every subsequent mention to suppress another offer for up to one hour even though the user received no link.

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/domain/use_cases/slack_gateway_use_case.py
Line: 1270

Comment:
**Failed DMs consume the cooldown**

When `conversations.open` or `chat.postMessage` fails, the cooldown has already been claimed and is not released, causing every subsequent mention to suppress another offer for up to one hour even though the user received no link.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

@michael-chou359
michael-chou359 merged commit 6101ea4 into main Aug 28, 2026
46 checks passed
@michael-chou359
michael-chou359 deleted the mc/slack-link-dm-trigger branch August 28, 2026 06:59
michael-chou359 added a commit that referenced this pull request Aug 30, 2026
…ink, stop leaking ids (#414)

Four fixes to the identity-link work. Three were found by using it — the
config surface
was too large, the connect link was somewhere people don't look, the
confirmation page
printed internal ids — and the fourth is a P1 the first one introduced,
caught in review.

---

# 1. Config surface: 8 vars → 0

#409#412 accumulated **eight** environment variables. One was a
hazard, one a trap,
six were knobs nobody has ever turned.

```
IDENTITY_LINK_SESSION_COOKIE_NAME    ->  derived from the delegation allowlist
IDENTITY_LINK_REQUIRE_EMAIL_MATCH    ->  enables itself when the scope exists
IDENTITY_LINK_NONCE_TTL              ->  constant
IDENTITY_LINK_MAX_DMS                ->  constant
IDENTITY_LINK_CACHE_TTL              ->  constant
IDENTITY_LINK_NEGATIVE_CACHE_TTL     ->  constant
IDENTITY_LINK_FALLBACK_TTL_DAYS      ->  constant
SLACK_LINK_OFFER_COOLDOWN_S          ->  constant
```

**No behavior change at current settings** — every constant equals the
default it
replaced.

## The hazard: a cookie name with two sources of truth

```
identity_link_service:  IDENTITY_LINK_SESSION_COOKIE_NAME         default _identityJwt
delegation_headers:     AGENTEX_DELEGATION_SESSION_COOKIE_NAMES   default _identityJwt
```

`acting_headers()` emits a `Cookie` header that
`build_delegation_headers` filters down
to its allowlist. Had the two disagreed, the credential would be
**stripped in transit**
— every linked turn silently losing its acting identity, while the link
sat in the
database looking stored, valid and healthy.

Now derived, so divergence is unrepresentable rather than warned about
in a comment. An
empty allowlist returns `None` and `acting_headers()` refuses, since
emitting a
credential that will certainly be stripped is worse than admitting we
can't act.

## The trap: a flag that had to move in lockstep with a Slack scope

The email check needs `users:read.email`, which isn't granted. The flag
kept it off
until then — but flag and scope had to be flipped **together**: the flag
alone refused
*every* link, the scope alone protected nothing.

It now enables itself: enforces whenever Slack answers with an email,
stands down when
it won't. Granting the scope switches the protection on by itself.

⚠️ This inverts the unverifiable case from refuse to allow — weaker,
deliberately. With
no flag to distinguish "scope missing" from "Slack had a bad minute",
failing closed
would make linking fail at random. The gap isn't attacker-reachable:
nobody outside our
infrastructure influences whether *our* Slack lookup succeeds. Against
what's deployed
today (flag off, verifying nothing) it's strictly stronger.

## What remains

| Variable | Why |
|---|---|
| `AGENTEX_CREDENTIAL_ENCRYPTION_KEY` | Secret, no default possible |
| `SLACK_GATEWAY_PUBLIC_BASE_URL` | Deployment-specific; the feature's
on-switch |
| `SLACK_GATEWAY_REQUIRE_LINKED_USER` | Pre-existing, a real product
choice |

---

# 2. The connect link was invisible

The first real offer in production was **delivered correctly and
reported as never
received**. Both were true: `chat.postMessage` returned `ok` and
`conversations.history` confirmed the message in the DM channel — and
Slack files bot
conversations under **Apps**, not in the Direct messages list. "I've
DM'd you a link"
pointed at the one place it wasn't.

```
before                                  after
ephemeral: "I've DM'd you a link —      ephemeral: "<Connect your SGP account>
            check your DMs"                         …also sent to <our DM>"
clicks to connect: 2, if you find it    clicks to connect: 1
```

## Why this doesn't weaken anything

An ephemeral has **exactly the same audience as a DM**: rendered for one
user, absent
from channel history and search. The exposure argument that made this
DM-only never
applied to an ephemeral — so routing someone through a conversation they
can't find, to
click a link we could hand them directly, bought nothing.

The DM stays because ephemerals are **transient**: reload before
clicking and it's gone,
and the offer cooldown would then block a retry for an hour. So the
ephemeral carries
the link *plus* a deep link to the DM as the durable copy.

## The invariant, stated properly

> The nonce is a bearer token. It may go anywhere **exactly one person
can see it** (the
> user's DM, an ephemeral addressed to them) and **nowhere that lands in
channel
> history**.

The test that asserted *"never in a payload addressed to the origin
channel"* now
asserts *"never in a `chat.postMessage` outside the user's own DM"*.
That matters — the
old wording would have blocked this change while the actual risk was
never touched by
it. A test encoding the implementation rather than the property blocks
correct changes.

## Also

- `conversations.open` moved above the send-cap check (idempotent; both
branches need
  the channel id).
- **Past the DM cap the user still gets the live link** — the cap limits
DMs, not what
  we can show the person in front of us.
- `slack.com/app_redirect`, not `slack://`, which fails on the web
client.

---

# 3. The connect page printed internal ids

It fell back to raw identifiers when it couldn't name an identity:

```python
slack_who = link_request.display_name or link_request.external_user_id  # U0B01457V24
<dd>{email or sgp_user_id}</dd>                                         # 5da8f784-…
```

That leaks an internal id and buys nothing for it. The two identity rows
exist so the
person clicking can answer *"is this **my** Slack account?"* and stop if
it isn't — the
only defence against a link forwarded to them. Nobody recognises their
own Slack member
id or SGP uuid, so the fallback never made that question answerable. It
made an
unanswerable question **look** answered, which is worse than showing
nothing.

- Neither side ever falls back to an id; an unnameable identity renders
a placeholder.
- A missing Slack name is now **re-read live** — a transient Slack
failure when the
nonce was minted shouldn't permanently degrade the page, and
`users.info` needs only
  `users:read`, which is granted.
- When either side is unnamed, the caution changes from *"if either name
above isn't
you, don't continue"* to saying the match can't be confirmed here.
Claiming someone
  verified something they couldn't is the actual harm.
- The success page likewise stops printing the uuid when there's no
email.

---

# 4. A multi-name cookie allowlist got narrowed (P1, introduced by §1)

Deriving the cookie name fixed one hazard and introduced another.
`session_cookie_name()`
returned only the **first** entry of
`AGENTEX_DELEGATION_SESSION_COOKIE_NAMES`, and the
read path matched against that alone — so with a multi-name allowlist, a
session carried
by any later name was rejected. Linking failed with *"couldn't read your
session"* for a
cookie the delegation layer would have forwarded quite happily.

The allowlist is the set of names a deployment treats as valid sessions,
so one arriving
under the second entry is exactly as legitimate as the first.

Reading and writing genuinely differ, so they're now separate functions
rather than one
doing double duty — which is how the bug got in:

```
session_cookie_names()   every accepted name, in preference order   (READ)
session_cookie_name()    the single canonical name to emit under    (WRITE)
```

Allowlist order beats header order when several are present, so the
stored credential is
the canonical cookie whenever it's there — which is also the name we
emit under, keeping
the common case exact. Emitting a value that arrived under a later name
as the canonical
one is safe: the credential is a session JWT, validated downstream on
its contents, not
on its label. Flagged in the docstring — if a downstream ever became
name-sensitive this
would need the originating name stored alongside the credential, which
is a schema change
and not worth making speculatively.

---

## Testing

**26 new, 6 rewritten.** The rewrites are the interesting ones — they
invert assertions
that encoded superseded designs:

| Was | Now |
|---|---|
| nonce must **not** appear in the ephemeral | nonce **must**
(single-viewer) |
| nothing addressed to the origin channel carries it | nothing
**broadcast** carries it |
| email flag off → no Slack call | check always runs; scope decides |
| unreadable email fails closed | fails open, loudly logged |

New coverage: the cookie name follows the delegation allowlist and the
emitted cookie
survives the delegation filter under a non-default name;
`acting_headers` refuses when
delegation is off; the ephemeral carries the link and points at the
durable copy; an
unreachable DM offers nothing.

**708 unit tests pass.** The 14 Redis integration tests still pass
against a real Redis
after the TTL constants moved. `ruff` clean.

## Not fixed here

The nonce is still 10 minutes — sized assuming prompt discovery, which
this incident
showed was optimistic. With the link now inline the find-it delay
largely disappears,
so I'd rather see whether it's still a problem than change two things at
once.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- greptile_comment -->

<h3>Greptile Summary</h3>

The PR simplifies identity-link configuration, improves Slack link
discoverability, removes raw identifiers from confirmation pages, and
fixes session extraction for multi-cookie delegation allowlists.

- Derives identity-link cookie handling from the delegation allowlist
and accepts sessions under any configured cookie name.
- Places the connection URL in the user-specific ephemeral response
while retaining the DM as a durable copy.
- Replaces internal identifier fallbacks with explicit unknown-identity
messaging and performs best-effort profile refreshes.
- Converts identity-link TTLs, limits, and cooldowns from environment
settings to fixed defaults.

<details><summary><h3>Confidence Score: 5/5</h3></summary>

The PR appears safe to merge because the previously reported
multi-cookie allowlist failure is fixed and no blocking eligible failure
remains.

No blocking failure remains.
</details>


<details><summary><h3>Important Files Changed</h3></summary>




| Filename | Overview |
|----------|----------|
| agentex/src/api/routes/integrations.py | Accepts every allowlisted
session-cookie name, adds best-effort identity naming and self-enabling
email comparison, and removes raw identifier fallbacks. |
| agentex/src/domain/services/identity_link_service.py | Derives
accepted and emitted cookie names from the delegation allowlist and
refuses acting headers when cookie delegation is disabled. |
| agentex/src/domain/services/link_nonce_service.py | Replaces nonce
lifetime and DM-send environment settings with constants matching their
former defaults. |
| agentex/src/domain/use_cases/slack_gateway_use_case.py | Surfaces the
bearer link in a user-specific ephemeral response, retains a durable DM
copy, and adds DM deep links. |
| agentex/tests/unit/api/test_integrations_routes.py | Adds regression
coverage for later allowlisted cookies, identity placeholders, live
Slack-name recovery, and email comparison behavior. |
| agentex/tests/unit/services/test_identity_link_service.py | Verifies
canonical cookie derivation, delegation forwarding, and
disabled-delegation behavior. |
| agentex/tests/unit/use_cases/test_slack_gateway_use_case.py | Verifies
that connection links remain single-viewer, discoverable, durable
through DM delivery, and available after the DM cap. |

</details>


<details><summary><h3>Flowchart</h3></summary>

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart LR
    Request[Authenticated link confirmation] --> Parse[Parse inbound cookies]
    Allowlist[Delegation cookie allowlist] --> Parse
    Parse --> Select[Select first present allowlisted cookie]
    Select --> Store[Encrypt and store session JWT]
    Store --> Resolve[Resolve linked Slack identity]
    Allowlist --> Canonical[Choose canonical first cookie name]
    Resolve --> Emit[Emit JWT under canonical cookie name]
    Emit --> Delegate[Delegation filter forwards acting-user cookie]
```
</details>

<sub>Reviews (4): Last reviewed commit: ["fix(agentex): accept a session
under
any..."](bbceae9)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=58328790)</sub>

<!-- /greptile_comment -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant